Offline et Persistance
Pourquoi la persistance ?
- Résilience réseau : mode avion, zones à faible débit, offline-first
- Expérience fluide : cache, préchargement, restauration d'état
- Performance : éviter les requêtes API répétées
- Fidélité utilisateur : app responsive même sans internet
Cache léger (clé/valeur) avec SharedPreferences
Installation et utilisation basique
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
return MaterialApp(
title: 'SharedPreferences Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const PreferencesDemo(),
);
}
}
class PreferencesDemo extends StatefulWidget {
const PreferencesDemo({super.key});
State<PreferencesDemo> createState() => _PreferencesDemoState();
}
class _PreferencesDemoState extends State<PreferencesDemo> {
String _theme = 'light';
int _version = 0;
bool _firstLaunch = true;
void initState() {
super.initState();
_loadPreferences();
}
Future<void> _loadPreferences() async {
final prefs = await SharedPreferences.getInstance();
setState(() {
_theme = prefs.getString('theme') ?? 'light';
_version = prefs.getInt('version') ?? 0;
_firstLaunch = prefs.getBool('firstLaunch') ?? true;
});
}
Future<void> _savePreferences() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('theme', _theme);
await prefs.setInt('version', _version);
await prefs.setBool('firstLaunch', _firstLaunch);
}
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Démonstration SharedPreferences'),
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Thème: $_theme'),
Text('Version: $_version'),
Text('Premier lancement: $_firstLaunch'),
const SizedBox(height: 20),
ElevatedButton(
onPressed: () async {
setState(() {
_theme = _theme == 'light' ? 'dark' : 'light';
});
await _savePreferences();
},
child: const Text('Changer le thème'),
),
],
),
),
);
}
}
Cas d'usage
- Préférences utilisateur (thème, langue, taille police)
- Flags (first launch, tutorials shown)
- Cache simple (last sync time, counters)
- Données non sensibles
Limitations
- Max ~5 MB par app
- Clé/valeur seulement (pas de structure)
- Pas de chiffrement (données en clair)
Stockage sécurisé avec flutter_secure_storage
Installation et utilisation
import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
return MaterialApp(
title: 'Secure Storage Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
useMaterial3: true,
),
home: const SecureStorageDemo(),
);
}
}
class SecureStorageDemo extends StatefulWidget {
const SecureStorageDemo({super.key});
State<SecureStorageDemo> createState() => _SecureStorageDemoState();
}
class _SecureStorageDemoState extends State<SecureStorageDemo> {
final storage = const FlutterSecureStorage();
String _token = '';
final _tokenController = TextEditingController();
Future<void> _saveToken() async {
await storage.write(
key: 'auth_token',
value: _tokenController.text,
);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Token sauvegardé de manière sécurisée')),
);
}
Future<void> _loadToken() async {
final token = await storage.read(key: 'auth_token');
setState(() {
_token = token ?? 'Aucun token trouvé';
});
}
Future<void> _deleteToken() async {
await storage.delete(key: 'auth_token');
setState(() {
_token = '';
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Token supprimé')),
);
}
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Stockage Sécurisé'),
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: _tokenController,
decoration: const InputDecoration(
labelText: 'Token d\'authentification',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _saveToken,
child: const Text('Sauvegarder le token'),
),
const SizedBox(height: 8),
ElevatedButton(
onPressed: _loadToken,
child: const Text('Charger le token'),
),
const SizedBox(height: 8),
ElevatedButton(
onPressed: _deleteToken,
child: const Text('Supprimer le token'),
),
const SizedBox(height: 20),
Text(
'Token chargé: $_token',
style: Theme.of(context).textTheme.bodyLarge,
),
],
),
),
);
}
}
Cas d'usage
- Tokens d'authentification (JWT, OAuth)
- Mots de passe
- Clés API sensibles
- Données personnelles (email, téléphone)
Avantages
- Chiffrement automatique (AES)
- Stockage sécurisé du système (Keychain iOS, Keystore Android)
- Données inaccessibles même si device rooté/jailbreaké
Données structurées avec sqflite (SQLite)
Setup basique
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
class DatabaseHelper {
static final DatabaseHelper _instance = DatabaseHelper._internal();
static Database? _database;
factory DatabaseHelper() {
return _instance;
}
DatabaseHelper._internal();
Future<Database> get database async {
if (_database != null) return _database!;
_database = await _initDatabase();
return _database!;
}
Future<Database> _initDatabase() async {
final dbPath = await getDatabasesPath();
final path = join(dbPath, 'app_database.db');
return await openDatabase(
path,
version: 1,
onCreate: (db, version) {
db.execute('''
CREATE TABLE users (
id INTEGER PRIMARY KEY,
name TEXT,
email TEXT,
createdAt TEXT
)
''');
},
);
}
}
import 'package:flutter/material.dart';
import 'database_helper.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
return MaterialApp(
title: 'SQLite Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
useMaterial3: true,
),
home: const UserListPage(),
);
}
}
class UserListPage extends StatefulWidget {
const UserListPage({super.key});
State<UserListPage> createState() => _UserListPageState();
}
class _UserListPageState extends State<UserListPage> {
final DatabaseHelper _dbHelper = DatabaseHelper();
List<Map<String, dynamic>> _users = [];
final _nameController = TextEditingController();
final _emailController = TextEditingController();
void initState() {
super.initState();
_loadUsers();
}
Future<void> _loadUsers() async {
final users = await _dbHelper.getUsers();
setState(() {
_users = users;
});
}
Future<void> _addUser() async {
if (_nameController.text.isNotEmpty && _emailController.text.isNotEmpty) {
await _dbHelper.insertUser({
'name': _nameController.text,
'email': _emailController.text,
'createdAt': DateTime.now().toIso8601String(),
});
_nameController.clear();
_emailController.clear();
await _loadUsers();
}
}
Future<void> _deleteUser(int id) async {
await _dbHelper.deleteUser(id);
await _loadUsers();
}
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Gestion des utilisateurs (SQLite)'),
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
TextField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Nom',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 8),
TextField(
controller: _emailController,
decoration: const InputDecoration(
labelText: 'Email',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 8),
ElevatedButton(
onPressed: _addUser,
child: const Text('Ajouter un utilisateur'),
),
],
),
),
Expanded(
child: ListView.builder(
itemCount: _users.length,
itemBuilder: (context, index) {
final user = _users[index];
return ListTile(
title: Text(user['name']),
subtitle: Text(user['email']),
trailing: IconButton(
icon: const Icon(Icons.delete),
onPressed: () => _deleteUser(user['id']),
),
);
},
),
),
],
),
);
}
void dispose() {
_nameController.dispose();
_emailController.dispose();
super.dispose();
}
}
CRUD operations
// Create
Future<int> insertUser(Map<String, dynamic> user) async {
final db = await database;
return await db.insert('users', user);
}
// Read
Future<List<Map>> getUsers() async {
final db = await database;
return await db.query('users');
}
// Update
Future<int> updateUser(int id, Map<String, dynamic> user) async {
final db = await database;
return await db.update('users', user, where: 'id = ?', whereArgs: [id]);
}
// Delete
Future<int> deleteUser(int id) async {
final db = await database;
return await db.delete('users', where: 'id = ?', whereArgs: [id]);
}
Migrations et versionning
// Augmenter version lors de changements de schéma
Future<Database> _initDatabase() async {
return await openDatabase(
path,
version: 2, // Nouvelle version
onUpgrade: (db, oldVersion, newVersion) {
if (oldVersion < 2) {
db.execute('ALTER TABLE users ADD COLUMN phone TEXT');
}
},
);
}
Stratégies offline
0. Détection de connectivité
Avant d'implémenter les stratégies offline, il faut pouvoir détecter l'état du réseau.
import 'package:flutter/material.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'dart:async';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
return MaterialApp(
title: 'Connectivity Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.cyan),
useMaterial3: true,
),
home: const ConnectivityDemo(),
);
}
}
class ConnectivityDemo extends StatefulWidget {
const ConnectivityDemo({super.key});
State<ConnectivityDemo> createState() => _ConnectivityDemoState();
}
class _ConnectivityDemoState extends State<ConnectivityDemo> {
final Connectivity _connectivity = Connectivity();
late StreamSubscription<ConnectivityResult> _connectivitySubscription;
String _connectionStatus = 'Vérification...';
bool _isOnline = false;
void initState() {
super.initState();
_checkConnectivity();
_connectivitySubscription = _connectivity.onConnectivityChanged.listen(_updateConnectionStatus);
}
Future<void> _checkConnectivity() async {
final result = await _connectivity.checkConnectivity();
_updateConnectionStatus(result);
}
void _updateConnectionStatus(ConnectivityResult result) {
setState(() {
if (result == ConnectivityResult.none) {
_connectionStatus = 'OFFLINE - Aucune connexion';
_isOnline = false;
} else if (result == ConnectivityResult.mobile) {
_connectionStatus = 'ONLINE - Données mobiles';
_isOnline = true;
} else if (result == ConnectivityResult.wifi) {
_connectionStatus = 'ONLINE - WiFi';
_isOnline = true;
} else {
_connectionStatus = 'ONLINE - ${result.name}';
_isOnline = true;
}
});
}
void dispose() {
_connectivitySubscription.cancel();
super.dispose();
}
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Détection de connectivité'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
_isOnline ? Icons.wifi : Icons.wifi_off,
size: 100,
color: _isOnline ? Colors.green : Colors.red,
),
const SizedBox(height: 20),
Text(
_connectionStatus,
style: Theme.of(context).textTheme.headlineSmall,
textAlign: TextAlign.center,
),
const SizedBox(height: 40),
ElevatedButton(
onPressed: _checkConnectivity,
child: const Text('Vérifier la connexion'),
),
],
),
),
);
}
}
1. Cache puis réseau (Cache-first)
Principe : Afficher immédiatement les données du cache local, puis rafraîchir en arrière-plan si connecté.
Comment ça marche :
- L'app cherche les données en cache d'abord
- Si trouvées, elle les affiche tout de suite (expérience fluide)
- Parallèlement, elle télécharge les nouvelles données depuis l'API
- Une fois reçues, elle met à jour le cache et affiche les données fraîches
Cas d'usage :
- Applications de news, blogs, réseaux sociaux (données peuvent être légèrement obsolètes)
- Catalogues de produits (mise à jour moins critique)
- Listes de favoris, commentaires
- Tout contexte où la réactivité prime sur la fraîcheur
Avantages :
- Excellente UX : interface disponible immédiatement
- Fonctionne parfaitement offline
- Économe en bande passante (rafraîchir seulement si nécessaire)
Inconvénients :
- Les utilisateurs voient d'abord les données obsolètes
- Besoin d'un système de versioning pour savoir si le cache est périmé
Future<List<Product>> getProducts({bool forceRefresh = false}) async {
// Afficher le cache immédiatement
final cached = await _localStore.getProducts();
if (cached.isNotEmpty && !forceRefresh) {
unawaited(_refreshInBackground()); // Rafraîchir en arrière-plan
return cached;
}
// Sinon, fetch depuis l'API
final fresh = await _api.fetchProducts();
await _localStore.saveProducts(fresh);
return fresh;
}
Future<void> _refreshInBackground() async {
try {
final fresh = await _api.fetchProducts();
await _localStore.saveProducts(fresh);
} catch (e) {
print('Refresh failed: $e');
}
}
2. Queue des actions (Action queuing)
Principe : Mettre en attente les actions utilisateur (création, modification) quand offline, puis les exécuter dès que la connexion revient.
Comment ça marche :
- L'utilisateur effectue une action (créer un commentaire, aimer un post)
- L'app teste si elle est connectée
- Si oui, elle envoie l'action directement au serveur
- Si non, elle l'ajoute à une file d'attente persistée (base de données)
- Dès que la connexion revient, elle traite la file d'attente
- Si l'app crash, au redémarrage les actions non complétées sont relancées
Important : Les actions doivent être persistées (sauvegardées en base de données), pas juste en mémoire. Sinon, si l'app ferme avant la synchro, les actions sont perdues.
Cas d'usage :
- Applications avec beaucoup de modifications utilisateur (todos, notes, messages)
- Réseaux sociaux (posts, commentaires, likes)
- Applications collaboratives
- Tout contexte où les modifications doivent être synchronisées
Avantages :
- L'app fonctionne normalement même offline
- Aucune donnée n'est perdue (même si crash)
- Transparence : utilisateur peut continuer à travailler
- Récupération automatique au redémarrage
Inconvénients :
- Complexité : gérer les conflits et les erreurs de sync
- Stockage de la file d'attente (peut grandir rapidement)
- Peut créer beaucoup d'actions en attente
class PendingAction {
final int id;
final String type; // 'create', 'update', 'delete'
final Map<String, dynamic> data;
final DateTime createdAt;
PendingAction({
required this.id,
required this.type,
required this.data,
required this.createdAt,
});
// Convertir en JSON pour la base de données
Map<String, dynamic> toJson() => {
'id': id,
'type': type,
'data': jsonEncode(data),
'createdAt': createdAt.toIso8601String(),
};
}
class ActionQueue {
final DatabaseHelper _db = DatabaseHelper();
final Connectivity _connectivity = Connectivity();
late StreamSubscription<ConnectivityResult> _connectivitySubscription;
ActionQueue() {
_connectivitySubscription = _connectivity.onConnectivityChanged.listen((result) {
if (result != ConnectivityResult.none) {
_processQueue();
}
});
}
// Ajouter une action à la file d'attente persistée
Future<void> queueAction(PendingAction action) async {
// 1. Sauvegarder localement d'abord
await _db.savePendingAction(action);
// 2. Essayer de synchroniser immédiatement si connecté
final result = await _connectivity.checkConnectivity();
if (result != ConnectivityResult.none) {
await _processQueue();
}
}
// Traiter toutes les actions en attente
Future<void> _processQueue() async {
final actions = await _db.getPendingActions();
for (final action in actions) {
try {
// Exécuter l'action sur le serveur
await _executeAction(action);
// Si succès, supprimer de la file d'attente
await _db.deletePendingAction(action.id);
} catch (e) {
// Si erreur, laisser en attente pour prochaine tentative
print('Failed to sync action ${action.id}: $e');
}
}
}
Future<void> _executeAction(PendingAction action) async {
switch (action.type) {
case 'create':
await _api.createItem(action.data);
break;
case 'update':
await _api.updateItem(action.data);
break;
case 'delete':
await _api.deleteItem(action.data['id']);
break;
}
}
void dispose() {
_connectivitySubscription.cancel();
}
}
Au démarrage de l'app :
void initState() {
super.initState();
// Relancer les actions non complétées depuis le dernier redémarrage
_actionQueue.processQueue();
}
Schéma de base de données :
CREATE TABLE pending_actions (
id INTEGER PRIMARY KEY,
type TEXT NOT NULL, -- 'create', 'update', 'delete'
data TEXT NOT NULL, -- JSON stringifiée
createdAt TEXT NOT NULL,
status TEXT DEFAULT 'pending' -- 'pending', 'syncing'
);
3. Sync automatique
Principe : Synchroniser régulièrement les données entre l'app et le serveur (dans les deux sens).
Comment ça marche :
- À intervalles réguliers (toutes les 5 min par exemple)
- L'app vérifie la connectivité
- Si connectée, elle :
- Envoie les modifications locales au serveur
- Télécharge les modifications du serveur
- Résout les conflits potentiels
- Continue à fonctionner pendant que la sync se fait en arrière-plan
Cas d'usage :
- Applications avec données partagées (équipes, documents collaboratifs)
- Calendriers, contacts partagés
- Bases de données distribuées
- Services où la cohérence est importante
Avantages :
- Tous les appareils restent synchronisés
- Fonctionne automatiquement, l'utilisateur ne doit rien faire
- Idéal pour données changeantes fréquemment
Inconvénients :
- Consommation batterie et data (sync régulière)
- Complexité de gestion des conflits
- Lag avant que les changements se propagent
class SyncManager {
final Duration syncInterval = const Duration(minutes: 5);
void startAutoSync() {
Timer.periodic(syncInterval, (_) async {
final connectivity = Connectivity();
final result = await connectivity.checkConnectivity();
if (result != ConnectivityResult.none) {
await syncData();
}
});
}
Future<void> syncData() async {
// Télécharger les nouvelles données
// Uploader les données locales
// Résoudre les conflits
}
}
Comparaison des stratégies
| Stratégie | Réactivité | Fraîcheur | Complexité | Cas d'usage |
|---|---|---|---|---|
| Cache-first | Très rapide | Faible | Basse | Lecture seule, news |
| Action queuing | Modérée | Très bonne | Haute | Modifications utilisateur |
| Sync auto | Modérée | Très bonne | Haute | Données partagées |
Gestion des conflits
Stratégies simples
// 1. Client wins (données locales prioritaires)
Future<void> syncData() async {
final local = await _localStore.getData();
final remote = await _api.getData();
if (local.version > remote.version) {
await _api.updateData(local); // Upload local
} else {
await _localStore.saveData(remote); // Use remote
}
}
// 2. Server wins (données serveur prioritaires)
Future<void> syncData() async {
final remote = await _api.getData();
await _localStore.saveData(remote);
}
// 3. Merge (fusion intelligente)
Future<void> syncData() async {
final local = await _localStore.getData();
final remote = await _api.getData();
final merged = _mergeData(local, remote);
await _localStore.saveData(merged);
await _api.updateData(merged);
}
Bonnes pratiques
- Toujours chiffrer les données sensibles (secure storage, pas SharedPreferences)
- Implémenter une versioning des données pour migrations propres
- Nettoyer les anciens caches régulièrement
- Tester les scénarios offline (désactiver WiFi, mode avion)
- Utiliser des indices de base de données pour performances
- Monitorer la taille du cache pour ne pas remplir le device
- Implémenter un système de retry avec exponential backoff
- Afficher clairement l'état de sync à l'utilisateur (syncing, synced, error)